Skip to main content

Building API Queries with PostgreSQL

After Seeding the Database

  • Once tables are created and seed data is inserted, the database contains sample entries.

  • Example tables now contain data:

    • users
    • user_profiles
    • projects
    • project_members
  • Example user table fields:

    • id
    • email
    • full_name
    • password_hash
  • Example profile table fields:

    • user_id
    • avatar_url
    • bio
    • phone

Purpose of Seeding

  • Provides test data for development.

  • Allows testing of:

    • API queries
    • Joins
    • Filtering
    • Sorting

API Example: Get All Users

Goal

Build an API:

GET /v1/users

This endpoint should:

  • Fetch all users
  • Include their profile information
  • Return everything in one API call

Reason:

  • Avoid making separate API calls for profile data.

Basic Query to Fetch Users

SELECT * FROM users;

Result

Returns:

  • All rows from the users table.

However:

  • It does not include profile data.

Fetching User Profiles with Users

Problem

User profile data exists in another table:

user_profiles

Relationship:

user_profiles.user_id → users.id

This is a foreign key relationship.


SQL JOIN

To combine user data with profile data, use a JOIN operation.


SQL Query Structure

Best practice when writing SQL:

Start with the FROM clause.

Reason:

  • Clearly defines where the data comes from.

Using Table Aliases

Example:

FROM users u

What is an Alias?

An alias is a short name for a table.

Example:

users → u

Benefits:

  • Shorter queries
  • Easier readability

Joining Profile Data

Since profile data is stored separately:

user_profiles

We join the tables.


LEFT JOIN

LEFT JOIN user_profiles up
ON u.id = up.user_id

Join Condition

users.id = user_profiles.user_id

Why LEFT JOIN Instead of INNER JOIN?

INNER JOIN

  • Requires rows to exist in both tables.

If a user does not have a profile entry, the result:

user not returned

LEFT JOIN

Returns:

  • All rows from the left table
  • Matching rows from the right table

If profile doesn't exist:

profile = NULL

Why this is important

Users may not have edited their profiles yet.

Therefore:

  • Profile rows may not exist.

We still want to return the user.


Selecting the Required Data

After defining the data source:

FROM users u
LEFT JOIN user_profiles up
ON u.id = up.user_id

We define what data we want.


Selecting User Fields

SELECT u.*

This returns:

  • All columns from the users table.

Embedding Profile Data as JSON

Instead of returning profile fields separately, convert them to JSON.

PostgreSQL function:

to_jsonb()

Query to Convert Profile Row to JSON

to_jsonb(up.*) AS profile

Explanation

  • up.* → entire profile row
  • to_jsonb() → converts row to JSON
  • AS profile → creates a new column named profile

Complete Query

SELECT 
u.*,
to_jsonb(up.*) AS profile
FROM users u
LEFT JOIN user_profiles up
ON u.id = up.user_id;

Example Result

Each user row now contains:

id
email
full_name
password_hash
created_at
updated_at
profile (JSON)

Example:

{
"id": "123",
"email": "john@example.com",
"full_name": "John Doe",
"profile": {
"bio": "Developer",
"avatar_url": "...",
"phone": "1234567890"
}
}

Sorting Query Results

SQL does not guarantee ordering by default.

Therefore:

  • Always explicitly sort results.

Sorting by Created Date

Most APIs return newest entries first.

ORDER BY u.created_at DESC

Meaning

  • Sort users by creation time
  • Latest users first

Final Query for GET /v1/users

SELECT 
u.*,
to_jsonb(up.*) AS profile
FROM users u
LEFT JOIN user_profiles up
ON u.id = up.user_id
ORDER BY u.created_at DESC;

API Data Flow

  1. Frontend calls:
GET /v1/users
  1. Backend executes SQL query.

  2. Database returns rows.

  3. Backend converts result to JSON.

  4. Response sent to frontend.


Serialization and Deserialization

Serialization

Converting internal data structures into JSON.

Example:

object → JSON

Deserialization

Converting database results into language-specific structures.

Example in Go:

SQL row → Go struct

Example Language Behavior

Node.js / JavaScript

  • JSON handling is natural.
  • Serialization overhead is small.

Go

Flow is typically:

DB row → struct → JSON → API response

API Example: Get Single User

Endpoint:

GET /v1/users/:userId

Example request:

/v1/users/abc123

Dynamic Parameters

userId is a dynamic parameter.

Backend extracts it from the URL.


Parameterized Queries

Instead of inserting the value directly into SQL, use parameterized queries.

Example concept:

SELECT * FROM users WHERE id = $1;

Where:

$1 = userId

Purpose of Parameterized Queries

Security

Protects against SQL Injection attacks.


SQL Injection Example

If queries are constructed using string concatenation:

SELECT * FROM users WHERE id = " + userInput

User might send:

1; DELETE FROM users

Result:

SELECT * FROM users WHERE id = 1; DELETE FROM users

This could delete all data.


How Parameterized Queries Prevent This

Parameterized queries treat user input as data only, not SQL.

Example:

SELECT * FROM users WHERE id = $1;

If user sends:

DELETE FROM users

Database treats it as:

"DELETE FROM users"

Just a string.

No SQL execution occurs.


Query to Fetch One User with Profile

SELECT 
u.*,
to_jsonb(up.*) AS profile
FROM users u
LEFT JOIN user_profiles up
ON u.id = up.user_id
WHERE u.id = :userId
order by u.created_at desc;

Where:

:userId = 'UUID'

all the code is to be wrapped in single codes to be interpreted as string COMES from : /v1/users/:userId


Result

Returns:

  • One user
  • Their profile
  • As a single row

Backend Execution Flow

Typical backend architecture:

Router

Handler

Service

Repository

Database Query

Steps:

  1. Router receives request.
  2. Extracts userId.
  3. Passes it to service layer.
  4. Repository executes parameterized query.
  5. Database returns result.
  6. Backend serializes JSON.
  7. Response sent to frontend.